Micron Document
🎖️GitЯра🎖️

Commit bfe3440a118861e800da83b57d7785b97642d597


Parents : 613dee9
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Date : 2026-06-16T08:33:45-05:00

fix(node): chart local-node air-quality telemetry (orphaning + zero-suppression) (#5793)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Changes
Diff

diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt
index 5d474b39fd..edf5f2694e 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt
@@ -19,6 +19,8 @@ package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@@ -80,10 +82,17 @@ class MeshMessageProcessorImpl(
}
init {
- nodeManager.isNodeDbReady
- .onEach { ready ->
- if (ready) {
- flushEarlyReceivedPackets("dbReady")
+ // Flush buffered packets only once BOTH the node DB is ready AND our own node number is known. Processing a
+ // received packet while myNodeNum is still null would key a local packet under its raw from_num instead of
+ // NODE_NUM_LOCAL (see [handleReceivedMeshPacket] / [processReceivedMeshPacket]), orphaning it from per-node
+ // queries. Emit the (ready, myNodeNum) pair — not the derived boolean — so distinctUntilChanged re-fires on
+ // every underlying state change (including a reconnect where myNodeNum transitions null -> value) rather than
+ // collapsing distinct states that happen to map to the same boolean.
+ combine(nodeManager.isNodeDbReady, nodeManager.myNodeNum) { ready, myNodeNum -> ready to myNodeNum }
+ .distinctUntilChanged()
+ .onEach { (ready, myNodeNum) ->
+ if (ready && myNodeNum != null) {
+ flushEarlyReceivedPackets("ready")
}
}
.launchIn(scope)
@@ -157,7 +166,12 @@ class MeshMessageProcessorImpl(
}
val preparedPacket = packet.copy(rx_time = rxTime)
- if (nodeManager.isNodeDbReady.value) {
+ // Require myNodeNum to be known before storing: processReceivedMeshPacket only keys a local packet under
+ // NODE_NUM_LOCAL when packet.from == myNodeNum. If myNodeNum is still null (early in a (re)connect, before
+ // MyNodeInfo resolves), a local packet would be stored under its raw from_num and become invisible to
+ // per-node chart queries while still appearing in the unfiltered Debug log. Buffer until both are ready;
+ // the init combine flushes the buffer once myNodeNum resolves.
+ if (nodeManager.isNodeDbReady.value && myNodeNum != null) {
processReceivedMeshPacket(preparedPacket, myNodeNum)
} else {
scope.launch {
@@ -175,6 +189,10 @@ class MeshMessageProcessorImpl(
private fun flushEarlyReceivedPackets(reason: String) {
scope.launch {
+ // Resolve and null-check myNodeNum BEFORE draining the buffer: if it regressed to null between the flush
+ // trigger and here, leave the packets buffered for the next resolution rather than draining and keying
+ // them under their raw from_num. The captured non-null value is used for the whole batch.
+ val myNodeNum = nodeManager.myNodeNum.value ?: return@launch
val packets =
earlyMutex.withLock {
if (earlyReceivedPackets.isEmpty()) return@withLock emptyList<MeshPacket>()
@@ -185,7 +203,6 @@ class MeshMessageProcessorImpl(
if (packets.isEmpty()) return@launch
Logger.d { "replayEarlyPackets reason=$reason count=${packets.size}" }
- val myNodeNum = nodeManager.myNodeNum.value
packets.forEach { processReceivedMeshPacket(it, myNodeNum) }
}
}

diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt
index 378f087ee2..83f92fff86 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt
@@ -22,6 +22,7 @@ import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
+import dev.mokkery.verify.VerifyMode
import dev.mokkery.verifySuspend
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -287,10 +288,13 @@ class MeshMessageProcessorImplTest {
// No crash, no emitMeshPacket call (decoded is null so processReceivedMeshPacket returns early)
}
- // ---------- handleReceivedMeshPacket: null myNodeNum ----------
+ // ---------- handleReceivedMeshPacket: myNodeNum not yet known ----------
@Test
- fun `processReceivedMeshPacket with null myNodeNum skips node updates`() = runTest(testDispatcher) {
+ fun `packets received before myNodeNum is known are buffered until it resolves`() = runTest(testDispatcher) {
+ // Our own node number is not yet known, even though the node DB is ready.
+ val myNodeNumFlow = MutableStateFlow<Int?>(null)
+ every { nodeManager.myNodeNum } returns myNodeNumFlow
processor = createProcessor(backgroundScope)
isNodeDbReady.value = true
@@ -305,7 +309,48 @@ class MeshMessageProcessorImplTest {
processor.handleReceivedMeshPacket(packet, null)
advanceUntilIdle()
- // emitMeshPacket should still be called, but node updates should be skipped
+ // Buffered, not processed: storing now could key a local packet under its raw from_num and orphan it from
+ // per-node chart queries. Neither the DB insert nor the downstream emit should have happened yet.
+ verifySuspend(mode = VerifyMode.not) { meshLogRepository.insert(any()) }
+ verifySuspend(mode = VerifyMode.not) { serviceRepository.emitMeshPacket(any()) }
+
+ // Once myNodeNum resolves, the buffer flushes and the packet is processed (stored + emitted).
+ myNodeNumFlow.value = 4321
+ advanceUntilIdle()
+ verifySuspend { meshLogRepository.insert(any()) }
+ verifySuspend { serviceRepository.emitMeshPacket(any()) }
+ }
+
+ @Test
+ fun `buffer survives a reconnect toggle and flushes only once myNodeNum resolves`() = runTest(testDispatcher) {
+ val myNodeNumFlow = MutableStateFlow<Int?>(null)
+ every { nodeManager.myNodeNum } returns myNodeNumFlow
+ processor = createProcessor(backgroundScope)
+ isNodeDbReady.value = true // DB ready, our node number still unknown
+
+ val packet =
+ MeshPacket(
+ id = 11,
+ from = 999,
+ decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = ByteString.EMPTY),
+ rx_time = 1700000000,
+ )
+ processor.handleReceivedMeshPacket(packet, null)
+ advanceUntilIdle()
+ verifySuspend(mode = VerifyMode.not) { serviceRepository.emitMeshPacket(any()) }
+
+ // Reconnect: isNodeDbReady toggles false -> true while myNodeNum stays null. The packet must stay buffered
+ // —
+ // flushing now would key it under its raw from_num.
+ isNodeDbReady.value = false
+ advanceUntilIdle()
+ isNodeDbReady.value = true
+ advanceUntilIdle()
+ verifySuspend(mode = VerifyMode.not) { serviceRepository.emitMeshPacket(any()) }
+
+ // Only once myNodeNum finally resolves does the buffer flush.
+ myNodeNumFlow.value = 4321
+ advanceUntilIdle()
verifySuspend { serviceRepository.emitMeshPacket(any()) }
}

diff --git a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/AirQualityChartReproTest.kt b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/AirQualityChartReproTest.kt
new file mode 100644
index 0000000000..c02dab230a
--- /dev/null
+++ b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/AirQualityChartReproTest.kt
@@ -0,0 +1,230 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.data.repository
+
+import dev.mokkery.MockMode
+import dev.mokkery.answering.returns
+import dev.mokkery.every
+import dev.mokkery.mock
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import okio.ByteString.Companion.toByteString
+import org.meshtastic.core.data.datasource.NodeInfoReadDataSource
+import org.meshtastic.core.data.manager.MeshMessageProcessorImpl
+import org.meshtastic.core.database.entity.MyNodeEntity
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.model.MeshLog
+import org.meshtastic.core.repository.FromRadioPacketHandler
+import org.meshtastic.core.repository.MeshDataHandler
+import org.meshtastic.core.repository.NodeManager
+import org.meshtastic.core.repository.ServiceStateWriter
+import org.meshtastic.core.testing.FakeDatabaseProvider
+import org.meshtastic.core.testing.FakeMeshLogPrefs
+import org.meshtastic.proto.AirQualityMetrics
+import org.meshtastic.proto.Data
+import org.meshtastic.proto.FromRadio
+import org.meshtastic.proto.MeshPacket
+import org.meshtastic.proto.PortNum
+import org.meshtastic.proto.Telemetry
+import kotlin.test.AfterTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+/**
+ * Repro for the field report: a node's air-quality telemetry shows up in the in-app Debug log but never appears in the
+ * Air Quality chart (PR #5701).
+ *
+ * Uses Brian's real packet: a TRANSPORT_INTERNAL TELEMETRY_APP packet from the locally-connected node (num [localNum])
+ * carrying non-zero PM (pm10_standard=1, pm25_standard=2, pm100_standard=2).
+ *
+ * The chart reads [MeshLogRepositoryImpl.getTelemetryFrom], which resolves the viewed node through `effectiveLogId` to
+ * [MeshLog.NODE_NUM_LOCAL] and filters `WHERE from_num = :fromNum`. The Debug screen reads the unfiltered
+ * `getAllLogsUnbounded`. So the chart is sensitive to the stored `from_num` column; the Debug log is not.
+ *
+ * `MeshMessageProcessorImpl.processReceivedMeshPacket` stores `fromNum = if (packet.from == myNodeNum) NODE_NUM_LOCAL
+ * else packet.from`, where the insert-time `myNodeNum` is `nodeManager.myNodeNum.value` read at packet arrival
+ * (MeshServiceOrchestrator). That StateFlow starts null and is only set once MyNodeInfo is processed, so a local packet
+ * that arrives during the null window is stored under its raw `from_num` and orphaned from the chart while still
+ * visible in the Debug log.
+ */
+class AirQualityChartReproTest {
+
+ private lateinit var dbProvider: FakeDatabaseProvider
+ private lateinit var meshLogPrefs: FakeMeshLogPrefs
+ private lateinit var nodeInfoReadDataSource: NodeInfoReadDataSource
+ private val testDispatcher = UnconfinedTestDispatcher()
+ private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher)
+ private lateinit var repository: MeshLogRepositoryImpl
+
+ private val nowMillis = 1_000_000_000L
+
+ /** Brian's connected node number, taken verbatim from his Debug log (`from=-93009324`). */
+ private val localNum = -93009324
+
+ private fun setup(myNodeNum: Int?) {
+ dbProvider = FakeDatabaseProvider()
+ meshLogPrefs = FakeMeshLogPrefs().apply { setLoggingEnabled(true) }
+ nodeInfoReadDataSource = mock(MockMode.autofill)
+ every { nodeInfoReadDataSource.myNodeInfoFlow() } returns MutableStateFlow(myNodeNum?.let(::myNodeEntity))
+ repository = MeshLogRepositoryImpl(dbProvider, dispatchers, meshLogPrefs, nodeInfoReadDataSource)
+ }
+
+ @AfterTest
+ fun tearDown() {
+ if (::dbProvider.isInitialized) dbProvider.close()
+ }
+
+ private fun myNodeEntity(num: Int) = MyNodeEntity(
+ myNodeNum = num,
+ model = "model",
+ firmwareVersion = "1.0",
+ couldUpdate = false,
+ shouldUpdate = false,
+ currentPacketId = 0L,
+ messageTimeoutMsec = 0,
+ minAppVersion = 0,
+ maxChannels = 0,
+ hasWifi = false,
+ )
+
+ /** Brian's real reading: pm10_standard=1, pm25_standard=2, pm100_standard=2. */
+ private fun brianAirQualityTelemetry() = Telemetry(
+ air_quality_metrics =
+ AirQualityMetrics(
+ pm10_standard = 1,
+ pm25_standard = 2,
+ pm100_standard = 2,
+ pm10_environmental = 1,
+ pm25_environmental = 2,
+ pm100_environmental = 2,
+ ),
+ )
+
+ private fun airQualityPacket() = MeshPacket(
+ from = localNum,
+ rx_time = 1_700_000_000,
+ decoded =
+ Data(payload = brianAirQualityTelemetry().encode().toByteString(), portnum = PortNum.TELEMETRY_APP),
+ )
+
+ private fun airQualityLog(fromNum: Int) = MeshLog(
+ uuid = "aq-$fromNum",
+ message_type = "Packet",
+ received_date = nowMillis,
+ raw_message = "",
+ fromNum = fromNum,
+ portNum = PortNum.TELEMETRY_APP.value,
+ fromRadio = FromRadio(packet = airQualityPacket()),
+ )
+
+ /** Checkpoint 1: the parse + query round-trip preserves the air-quality payload (rules out content loss). */
+ @Test
+ fun `checkpoint 1 - air quality payload survives the telemetry round-trip`() = runTest(testDispatcher) {
+ setup(myNodeNum = null) // effectiveLogId(0) -> 0
+ repository.insert(airQualityLog(fromNum = 0))
+
+ val result = repository.getTelemetryFrom(0).first()
+
+ assertEquals(1, result.size, "the air-quality telemetry row should round-trip")
+ assertEquals(2, result[0].air_quality_metrics?.pm25_standard, "pm25_standard must survive decode")
+ }
+
+ /** Checkpoint 2: local-node air-quality stored under NODE_NUM_LOCAL is returned to the chart (the happy path). */
+ @Test
+ fun `checkpoint 2 - local AQ stored under NODE_NUM_LOCAL is charted`() = runTest(testDispatcher) {
+ setup(myNodeNum = localNum) // viewing the local node -> effectiveLogId -> NODE_NUM_LOCAL
+ // myNodeNum known at insert -> processReceivedMeshPacket would store NODE_NUM_LOCAL.
+ repository.insert(airQualityLog(fromNum = MeshLog.NODE_NUM_LOCAL))
+
+ val result = repository.getTelemetryFrom(localNum).first()
+
+ assertEquals(1, result.size, "AQ stored under NODE_NUM_LOCAL should be visible to the local node's chart")
+ }
+
+ /**
+ * Checkpoint 3 (query invariant — the rationale for the insert-side fix): the per-node chart query keys the local
+ * node on NODE_NUM_LOCAL, so a row stored under the raw myNodeNum is not returned (though the unfiltered Debug log
+ * still shows it). This is *why* the insert path must key local packets under NODE_NUM_LOCAL — verified in
+ * checkpoint 4. This query behavior is intentional and unchanged by the fix.
+ */
+ @Test
+ fun `checkpoint 3 - query keys local node on NODE_NUM_LOCAL not the raw from_num`() = runTest(testDispatcher) {
+ setup(myNodeNum = localNum) // viewing the local node -> effectiveLogId -> NODE_NUM_LOCAL
+ repository.insert(airQualityLog(fromNum = localNum)) // a hypothetical mis-keyed row
+
+ // Debug screen (unfiltered) sees it:
+ assertEquals(
+ 1,
+ repository.getAllLogsUnbounded().first().size,
+ "Debug log shows rows regardless of from_num",
+ )
+
+ // Chart query (from_num = NODE_NUM_LOCAL) does not — hence the insert must never produce a raw-keyed local
+ // row:
+ val charted = repository.getTelemetryFrom(localNum).first()
+ assertTrue(charted.isEmpty(), "the local-node query only matches NODE_NUM_LOCAL")
+ }
+
+ /**
+ * Checkpoint 4 (regression test for the fix, end-to-end through the real insert path): a local air-quality packet
+ * received while myNodeNum is still null is BUFFERED (not stored under its raw from_num). Once myNodeNum resolves,
+ * the buffer flushes and the packet is stored under NODE_NUM_LOCAL, so the local node's Air Quality chart sees it.
+ *
+ * Before the fix this packet was stored immediately under its raw from_num and orphaned from the chart.
+ */
+ @Test
+ fun `checkpoint 4 - local AQ received before myNodeNum resolves is buffered then charted`() =
+ runTest(testDispatcher) {
+ setup(myNodeNum = localNum) // query side: phone is connected to localNum
+
+ val myNodeNumFlow = MutableStateFlow<Int?>(null) // not yet resolved
+ val nodeManager = mock<NodeManager>(MockMode.autofill)
+ every { nodeManager.isNodeDbReady } returns MutableStateFlow(true)
+ every { nodeManager.myNodeNum } returns myNodeNumFlow
+
+ val processor =
+ MeshMessageProcessorImpl(
+ nodeManager = nodeManager,
+ serviceStateWriter = mock<ServiceStateWriter>(MockMode.autofill),
+ meshLogRepository = lazy { repository },
+ dataHandler = lazy { mock<MeshDataHandler>(MockMode.autofill) },
+ fromRadioDispatcher = mock<FromRadioPacketHandler>(MockMode.autofill),
+ scope = backgroundScope,
+ )
+
+ // Arrives before MyNodeInfo resolves -> buffered, NOT written to the log table (so not orphaned in the DB).
+ processor.handleReceivedMeshPacket(airQualityPacket(), myNodeNum = null)
+ advanceUntilIdle()
+ assertEquals(0, repository.getAllLogsUnbounded().first().size, "packet should be buffered, not yet stored")
+
+ // MyNodeInfo resolves -> the buffer flushes and the packet is stored under NODE_NUM_LOCAL.
+ myNodeNumFlow.value = localNum
+ advanceUntilIdle()
+
+ val charted = repository.getTelemetryFrom(localNum).first()
+ assertEquals(1, charted.size, "after myNodeNum resolves, the local AQ packet must reach the chart")
+ assertEquals(
+ 2,
+ charted[0].air_quality_metrics?.pm25_standard,
+ "the real reading (pm25_standard=2) survives",
+ )
+ }
+}

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/AirQualityMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/AirQualityMetrics.kt
index ad46dd396c..03504af65a 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/AirQualityMetrics.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/AirQualityMetrics.kt
@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
-@file:Suppress("MagicNumber")
+@file:Suppress("MagicNumber", "MatchingDeclarationName") // file groups the AirQuality enum with its chart composables
package org.meshtastic.feature.node.metrics
@@ -73,8 +73,10 @@ import org.meshtastic.core.ui.util.rememberSaveFileLauncher
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.AirQualityMetrics as AirQualityMetricsProto
-/** Selectable chart metric enum for air quality data series. */
-private enum class AirQuality(val labelRes: StringResource, val unit: String, val color: Color) {
+/**
+ * Selectable chart metric enum for air quality data series. Internal (not private) so [getValue] can be unit-tested.
+ */
+internal enum class AirQuality(val labelRes: StringResource, val unit: String, val color: Color) {
PM1_0(Res.string.pm1_0, "µg/m³", Blue),
PM2_5(Res.string.pm2_5, "µg/m³", Cyan),
PM10(Res.string.pm10, "µg/m³", Green),
@@ -83,11 +85,14 @@ private enum class AirQuality(val labelRes: StringResource, val unit: String, va
fun getValue(telemetry: Telemetry): Float? {
val aq = telemetry.air_quality_metrics ?: return null
+ // A field that is present-and-zero is a real reading (e.g. a PM sensor in clean air reports 0 µg/m³) and must
+ // be plotted. The `?.` already excludes genuinely-absent fields (Wire decodes an unset optional uint32 to
+ // null), so no zero-suppression guard is needed — adding one would discard valid clean-air data.
return when (this) {
- PM1_0 -> aq.pm10_standard?.takeIf { it != 0 }?.toFloat()
- PM2_5 -> aq.pm25_standard?.takeIf { it != 0 }?.toFloat()
- PM10 -> aq.pm100_standard?.takeIf { it != 0 }?.toFloat()
- CO2 -> aq.co2?.takeIf { it != 0 }?.toFloat()
+ PM1_0 -> aq.pm10_standard?.toFloat()
+ PM2_5 -> aq.pm25_standard?.toFloat()
+ PM10 -> aq.pm100_standard?.toFloat()
+ CO2 -> aq.co2?.toFloat()
}
}
}
@@ -283,28 +288,21 @@ private fun AirQualityMetricsCard(
Spacer(modifier = Modifier.height(4.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Column {
- aq.pm10_standard
- ?.takeIf { it != 0 }
- ?.let { Text("PM1.0: $it µg/m³", style = MaterialTheme.typography.bodySmall) }
- aq.pm25_standard
- ?.takeIf { it != 0 }
- ?.let { Text("PM2.5: $it µg/m³", style = MaterialTheme.typography.bodySmall) }
- aq.pm100_standard
- ?.takeIf { it != 0 }
- ?.let { Text("PM10: $it µg/m³", style = MaterialTheme.typography.bodySmall) }
+ // Present-and-zero is a valid clean-air reading; only `?.` (absent field) hides a row.
+ aq.pm10_standard?.let { Text("PM1.0: $it µg/m³", style = MaterialTheme.typography.bodySmall) }
+ aq.pm25_standard?.let { Text("PM2.5: $it µg/m³", style = MaterialTheme.typography.bodySmall) }
+ aq.pm100_standard?.let { Text("PM10: $it µg/m³", style = MaterialTheme.typography.bodySmall) }
}
Column {
- aq.co2
- ?.takeIf { it != 0 }
- ?.let { co2 ->
- val severity = Co2Severity.fromPpm(co2)
- Text(
- text = "CO₂: $co2 ppm",
- style = MaterialTheme.typography.bodySmall,
- fontWeight = FontWeight.Medium,
- color = severity?.color ?: MaterialTheme.colorScheme.onSurface,
- )
- }
+ aq.co2?.let { co2 ->
+ val severity = Co2Severity.fromPpm(co2)
+ Text(
+ text = "CO₂: $co2 ppm",
+ style = MaterialTheme.typography.bodySmall,
+ fontWeight = FontWeight.Medium,
+ color = severity?.color ?: MaterialTheme.colorScheme.onSurface,
+ )
+ }
}
}
}

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
index c605e4711d..12e1b82679 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
@@ -453,32 +453,34 @@ open class MetricsViewModel(
rows = data,
epochSeconds = { it.time.toLong() },
) { t ->
+ // Present-and-zero is a real reading and must be exported (matching the chart/card); only a genuinely
+ // absent field (null) renders as an empty cell. No zero-suppression guards here.
val aq = t.air_quality_metrics
- "\"${aq?.pm10_standard?.takeIf { it != 0 } ?: ""}\"," +
- "\"${aq?.pm25_standard?.takeIf { it != 0 } ?: ""}\"," +
- "\"${aq?.pm100_standard?.takeIf { it != 0 } ?: ""}\"," +
- "\"${aq?.pm10_environmental?.takeIf { it != 0 } ?: ""}\"," +
- "\"${aq?.pm25_environmental?.takeIf { it != 0 } ?: ""}\"," +
- "\"${aq?.pm100_environmental?.takeIf { it != 0 } ?: ""}\"," +
- "\"${aq?.particles_03um?.takeIf { it != 0 } ?: ""}\"," +
- "\"${aq?.particles_05um?.takeIf { it != 0 } ?: ""}\"," +
- "\"${aq?.particles_10um?.takeIf { it != 0 } ?: ""}\"," +
- "\"${aq?.particles_25um?.takeIf { it != 0 } ?: ""}\"," +
- "\"${aq?.particles_50um?.takeIf { it != 0 } ?: ""}\"," +
- "\"${aq?.particles_100um?.takeIf { it != 0 } ?: ""}\"," +
- "\"${aq?.co2?.takeIf { it != 0 } ?: ""}\"," +
- "\"${aq?.co2_temperature?.takeIf { it != 0f } ?: ""}\"," +
- "\"${aq?.co2_humidity?.takeIf { it != 0f } ?: ""}\"," +
- "\"${aq?.form_formaldehyde?.takeIf { it != 0f } ?: ""}\"," +
- "\"${aq?.form_humidity?.takeIf { it != 0f } ?: ""}\"," +
- "\"${aq?.form_temperature?.takeIf { it != 0f } ?: ""}\"," +
- "\"${aq?.pm40_standard?.takeIf { it != 0 } ?: ""}\"," +
- "\"${aq?.particles_40um?.takeIf { it != 0 } ?: ""}\"," +
- "\"${aq?.pm_temperature?.takeIf { it != 0f } ?: ""}\"," +
- "\"${aq?.pm_humidity?.takeIf { it != 0f } ?: ""}\"," +
- "\"${aq?.pm_voc_idx?.takeIf { it != 0f } ?: ""}\"," +
- "\"${aq?.pm_nox_idx?.takeIf { it != 0f } ?: ""}\"," +
- "\"${aq?.particles_tps?.takeIf { it != 0f } ?: ""}\""
+ "\"${aq?.pm10_standard ?: ""}\"," +
+ "\"${aq?.pm25_standard ?: ""}\"," +
+ "\"${aq?.pm100_standard ?: ""}\"," +
+ "\"${aq?.pm10_environmental ?: ""}\"," +
+ "\"${aq?.pm25_environmental ?: ""}\"," +
+ "\"${aq?.pm100_environmental ?: ""}\"," +
+ "\"${aq?.particles_03um ?: ""}\"," +
+ "\"${aq?.particles_05um ?: ""}\"," +
+ "\"${aq?.particles_10um ?: ""}\"," +
+ "\"${aq?.particles_25um ?: ""}\"," +
+ "\"${aq?.particles_50um ?: ""}\"," +
+ "\"${aq?.particles_100um ?: ""}\"," +
+ "\"${aq?.co2 ?: ""}\"," +
+ "\"${aq?.co2_temperature ?: ""}\"," +
+ "\"${aq?.co2_humidity ?: ""}\"," +
+ "\"${aq?.form_formaldehyde ?: ""}\"," +
+ "\"${aq?.form_humidity ?: ""}\"," +
+ "\"${aq?.form_temperature ?: ""}\"," +
+ "\"${aq?.pm40_standard ?: ""}\"," +
+ "\"${aq?.particles_40um ?: ""}\"," +
+ "\"${aq?.pm_temperature ?: ""}\"," +
+ "\"${aq?.pm_humidity ?: ""}\"," +
+ "\"${aq?.pm_voc_idx ?: ""}\"," +
+ "\"${aq?.pm_nox_idx ?: ""}\"," +
+ "\"${aq?.particles_tps ?: ""}\""
}
}

diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/AirQualityMetricsTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/AirQualityMetricsTest.kt
new file mode 100644
index 0000000000..2ef8733066
--- /dev/null
+++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/AirQualityMetricsTest.kt
@@ -0,0 +1,72 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.node.metrics
+
+import org.meshtastic.proto.AirQualityMetrics
+import org.meshtastic.proto.Telemetry
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+/**
+ * Unit tests for the air-quality chart's value extraction ([AirQuality.getValue]).
+ *
+ * Locks the BUG B fix: a present-and-zero reading (a PM sensor in clean air reports 0 µg/m³) must be plotted, while a
+ * genuinely absent field stays null so the metric is neither charted nor offered as a selectable chip. Also pins the
+ * assumption that a partial-sensor node (e.g. CO2 only) leaves the other fields unset/null rather than zero.
+ */
+class AirQualityMetricsTest {
+
+ private fun telemetry(aq: AirQualityMetrics) = Telemetry(air_quality_metrics = aq)
+
+ @Test
+ fun `getValue returns present non-zero readings`() {
+ val t = telemetry(AirQualityMetrics(pm10_standard = 1, pm25_standard = 2, pm100_standard = 3, co2 = 800))
+ assertEquals(1f, AirQuality.PM1_0.getValue(t))
+ assertEquals(2f, AirQuality.PM2_5.getValue(t))
+ assertEquals(3f, AirQuality.PM10.getValue(t))
+ assertEquals(800f, AirQuality.CO2.getValue(t))
+ }
+
+ @Test
+ fun `getValue plots a present-zero reading instead of suppressing it`() {
+ // BUG B regression: clean air reads 0 µg/m³ and must chart as 0f, not be dropped to null.
+ val t = telemetry(AirQualityMetrics(pm10_standard = 0, pm25_standard = 0, pm100_standard = 0, co2 = 0))
+ assertEquals(0f, AirQuality.PM1_0.getValue(t))
+ assertEquals(0f, AirQuality.PM2_5.getValue(t))
+ assertEquals(0f, AirQuality.PM10.getValue(t))
+ assertEquals(0f, AirQuality.CO2.getValue(t))
+ }
+
+ @Test
+ fun `getValue returns null for an absent field so a partial-sensor node does not chart spurious series`() {
+ // A CO2-only node leaves the PM fields unset (Wire decodes an unset optional uint32 to null).
+ val t = telemetry(AirQualityMetrics(co2 = 450))
+ assertNull(AirQuality.PM1_0.getValue(t))
+ assertNull(AirQuality.PM2_5.getValue(t))
+ assertNull(AirQuality.PM10.getValue(t))
+ assertEquals(450f, AirQuality.CO2.getValue(t))
+ }
+
+ @Test
+ fun `getValue returns null for every series when there are no air quality metrics`() {
+ val t = Telemetry()
+ AirQuality.entries.forEach {
+ assertNull(it.getValue(t), "${it.name} should be null without air_quality_metrics")
+ }
+ }
+}

Served by rngit 1.5.0 - Generated in 0.1s